Skip to content

Expose event release lifecycle metadata - #688

Open
alisawavezen12 wants to merge 6 commits into
masterfrom
feat/event-release-metadata
Open

alisawavezen12 wants to merge 6 commits into
masterfrom
feat/event-release-metadata

Conversation

@alisawavezen12

@alisawavezen12 alisawavezen12 commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Adding "Resolved", "New", "Regression" label to events. Related codex-team/hawk.workers#590

Summary

  • Add nullable firstSeenInRelease, resolvedInRelease, and regressionInRelease fields to GraphQL Event.
  • Preserve the original event's release when viewing a repetition.

Include unchecked releases without events and ignore empty or
placeholder release names.

@neSpecc neSpecc left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few notes on the release metadata. The most important one is the unguarded event.payload access in _composeEventWithRepetition; the rest are about consistency of release names and the cost/semantics of latestRelease.

Comment thread src/models/eventsFactory.js Resolved
Comment thread src/models/releasesFactory.ts Resolved
Comment thread src/models/releasesFactory.ts Resolved
Comment thread src/resolvers/project.js Resolved
Comment thread src/models/releasesFactory.ts Resolved
*/
private readonly collection: Collection<ReleaseDBScheme>;
private readonly filesCollection: Collection<ReleaseFileDBScheme>;
private readonly latestRegisteredReleaseByProject = new Map<string, Promise<string | null>>();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docs missed

release: 1,
},
}).then(doc => normalizeReleaseName(doc?.release));
this.latestRegisteredReleaseByProject.set(key, latest);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have 2 kind of factory calls:

  1. on request call (factory will be cleared after request along with this "cache")
  2. sharedFactories (used for SSH and GitHub) — it that case this cache would be never cleared.

Consider using DataLoader with case: true option. Like we have near planById.

Comment thread src/resolvers/event.js
/**
* Returns the original release, preserving null for repetitions of legacy events.
*
* @param {Object} event - original event or composed repetition

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jsdoc params are not fit actual params (firstSeenInRelease, payload)

Comment thread src/resolvers/event.js
},
Event: {
/**
* Returns the original release, preserving null for repetitions of legacy events.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* Returns the original release, preserving null for repetitions of legacy events.
* Returns the original release name, preserving null for repetitions of legacy events.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The newest-release lookup uses an index that cannot satisfy the _id sort, forcing each new request to scan the project's full release history.

1 open issue
What changed in this PR

Exposes event release lifecycle metadata through GraphQL to support New, Resolved, and Regression labels while preserving the original event’s release when viewing repetitions.

Changes:

  • Adds nullable lifecycle release fields and an isNew flag based on the newest registered project release.
  • Normalizes release identifiers and preserves original metadata across event composition paths.
  • Adds utility, factory, and GraphQL tests; bumps the package version to 1.7.0.
File Description
test/​utils/​releaseName.test.ts Tests release identifier normalization.
test/​resolvers/​event-release-metadata.test.ts Tests GraphQL metadata, isNew, and request caching.
test/​models/​releasesFactory-is-new.test.ts Tests latest-release comparisons, caching, and Mongo query behavior.
test/​models/​eventsFactory-release-metadata.test.ts Tests metadata preservation across originals and repetitions.
src/​utils/​releaseName.ts Adds shared release-name normalization.
src/​typeDefs/​event.ts Declares lifecycle release fields and isNew.
src/​resolvers/​event.js Resolves normalized metadata and release-based newness.
src/​models/​releasesFactory.ts Adds a cached latest-registered-release lookup.
src/​models/​eventsFactory.js Preserves original lifecycle metadata during composition.
package.json Bumps the version to 1.7.0.

Review effort: Balanced

Comment thread src/models/releasesFactory.ts Resolved
projectId: 1,
release: 1,
},
}).then(doc => normalizeReleaseName(doc?.release));

@neSpecc neSpecc Nov 1, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

findOne with sort: { _id: -1 } always takes the newest release document. normalizeReleaseName then turns '', whitespace, 'undefined', 'null', and 'NaN' into null, and name === null is false for every real event release. isNew stays false for the whole project until a newer document with a valid name is inserted.

Example: CI uploads release: process.env.VERSION while the variable is unset, so the newest document is stored as 'undefined'. Events first seen in the previous real release are not labeled New, and neither are events from that 'undefined' build.

Skip documents whose name normalizes to null and compare against the newest remaining one. The same { projectId, release } index still bounds the scan; stop at the first valid document instead of treating a placeholder as "there is no latest release". does not skip an invalid newest document currently locks this in.

const cursor = this.collection.find({ projectId: key }, {
  sort: { _id: -1 },
  projection: { release: 1, _id: 0 },
  hint: { projectId: 1, release: 1 },
});

try {
  for await (const doc of cursor) {
    const latestName = normalizeReleaseName(doc.release);

    if (latestName !== null) {
      return latestName;
    }
  }

  return null;
} finally {
  await cursor.close();
}

this.latestRegisteredReleaseByProject.set(key, latest);
}

return name === await latest;

@neSpecc neSpecc Nov 1, 2026 •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If findOne rejects, this await throws out of isNew. isNew is Boolean! and DailyEvent.event is Event!, so GraphQL drops the whole Event, then the whole DailyEvent. dailyEvents: [DailyEvent] keeps the list, but every row becomes null. The rejected promise is cached on the factory, so one Mongo failure does this to every event of the project in the request. The client gets a portion of nulls and the errors array, while the events are still in the database.

false is a valid Boolean!, so the row can stay on screen and the New badge stays off for this request. The factory is created per request, so the next request tries findOne again. Log the error; otherwise a Mongo failure looks like "nothing is new".

Catch the whole method, not only the await. findOne can throw synchronously (bad options, missing hinted index) before a promise exists, and that throw never reaches a catch wrapped around return name === await latest. The cache lookup stays inside the same try, so a cached rejection is handled too.

public async isNewInLatestRelease(
  projectId: string | ObjectId | null | undefined,
  firstSeenRelease: unknown
): Promise<boolean> {
  try {
    const name = normalizeReleaseName(firstSeenRelease);

    if (!projectId || name === null) {
      return false;
    }

    const key = projectId.toString();
    let latest = this.latestRegisteredReleaseByProject.get(key);

    if (!latest) {
      latest = this.collection.findOne({ projectId: key }, {
        sort: { _id: -1 },
        projection: { release: 1, _id: 0 },
        hint: { projectId: 1, release: 1 },
      }).then(doc => normalizeReleaseName(doc?.release));
      this.latestRegisteredReleaseByProject.set(key, latest);
    }

    return name === await latest;
  } catch (error) {
    console.error('Failed to resolve latest release', error);
    return false;
  }
}

The integration test does not catch this: its release documents are tiny, findOne resolves, and isNew returns a boolean.

Add this to test/resolvers/event-release-metadata.test.ts. The hand-built schema has events: [Event!]!, so today the query comes back as data: null plus an error per row. After the catch it should come back as booleans and no errors:

it('keeps the event list when the latest-release query fails', async () => {
  findOne.mockRejectedValue(new Error('mongo unavailable'));
  const events = [
    { projectId, payload: { release: latestName } },
    { projectId, payload: { release: 'older' } },
  ];
  const { data, errors } = await graphql({
    schema,
    source: '{ events { isNew } }',
    rootValue: { events },
    contextValue: context,
  });

  expect(errors).toBeUndefined();
  expect(data?.events).toEqual([{ isNew: false }, { isNew: false }]);
  expect(findOne).toHaveBeenCalledTimes(1);
});

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants